【LIFF】LINEのユーザー情報をサーバーサイドで使用する際のアンチパターンと適切な実装方法のご紹介

【LIFF】LINEのユーザー情報をサーバーサイドで使用する際のアンチパターンと適切な実装方法のご紹介

Clock Icon2024.09.29

リテールアプリ共創部のるおんです。
先日LIFFアプリの開発において、必要なLINEのユーザー情報をデータベースに保存する機能を開発する機会がありました。
LIFFアプリを開発する際、ユーザー情報の取り扱いは非常に重要なポイントです。今回は、LINEのユーザー情報をサーバーで使用する際のアンチパターンと、その正しい対応方法について解説します。

ユースケース

LIFFアプリを開発する中で、以下のようなユースケースがよくあります:

  • ユーザーのプロフィール情報をアプリ内で表示する
  • ユーザーの基本情報をデータベースに保存し、アプリの機能をパーソナライズする
  • ユーザーの識別情報を使用して、アプリ内での活動を追跡する

これらのユースケースを実現するために、LINEのユーザー情報を取得し、サーバーサイドで処理する必要があります。例えば、ユーザーがアプリを初めて使用する際にその人のLINEプロフィール情報(名前、プロフィール画像など)を取得し、アプリのデータベースに保存したいという要件がよくあります。
他にも、取得したユーザーをカテゴライズしてUIDを用いて特定のユーザー群にのみLINEメッセージを一斉配信したいなどの要件もあるかもしれません。

ユーザー情報の取得方法

そもそも、LIFFアプリを開発するにあたってユーザー情報を取得するには大きく分けて2パターンがあります。

しかし、これらのAPIを用いて取得したユーザー情報は適切に取り扱う必要があります。

ユーザー情報の取扱方法

例えば、LIFFアプリを使用したユーザーのデータをデータベースに保存したいと思います。
以下はアンチパターンです。

アンチパターン

  1. クライアントサイドでLIFFアプリを構築し、liff.getProfileメソッドを用いて取得したユーザー情報を取得します
  2. 取得したユーザー情報をサーバーサイドにリクエスト送信します。
  3. サーバーサイドでクライアントから渡ってきたユーザー情報を処理してDBに保存します。
    このような実装はセキュリティの観点から問題があります。

スクリーンショット 2024-09-29 15.06.34

正しいアプローチ

代わりに、以下のような方法を採用すべきです:

  1. クライアントサイドでは、LIFFのIDトークン、またはアクセストークンのみを取得します。
  2. このIDトークン(or アクセストークン)をサーバーサイドに送信します。
  3. クライアントサイドから渡ってきたIDトークン(or アクセストークン)を用いて、サーバーサイドでLINEログインAPIを使用してユーザー情報を取得します。
  4. 取得したユーザー情報をデータベースに保存します。

スクリーンショット 2024-09-29 15.20.19

LINEの公式ドキュメントにもユーザー情報を適切に扱うためのシーケンス図がわかりやすく載っています。

IDトークンの場合
アクセストークンの場合

なぜユーザー情報の取り扱いに注意が必要か

LIFFアプリでユーザー情報を扱う際、セキュリティとプライバシーの観点から、適切な方法で情報を取得し、サーバーに送信することが重要です。LINEの公式ドキュメントでも、以下のように注意喚起されています:

ユーザー情報をサーバーに送信しないでください
liff.getDecodedIDToken()およびliff.getProfile()で取得したユーザーのプロフィールの詳細を、LIFFアプリからサーバーに送信しないでください。

LIFFアプリで、これらのユーザー情報を正しく処理しないと、なりすましやその他の種類の攻撃に対して脆弱になります。

実際にユーザー情報を正しく取得してみた

全体像

実装する全体像は以下のとおりです。今回はアクセストークンを使用してユーザー情報を取得したいと思います。

  • クライアントサイドではReactを用いてLIFFアプリを作成します。
  • サーバーサイドにはLambda関数でNode.jsを実行します。
  • データベースにDynamoDBを使用してユーザー情報を保存します。。

スクリーンショット 2024-09-29 15.31.38

では早速実装していきたいと思います。
必要なインフラリソースはAWS CDKでサクッと作っちゃいます。

user-profile-cdk-stack
user-profile-cdk-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as aws_dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { RemovalPolicy } from 'aws-cdk-lib';

export class UserProfileCdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    /**
     * API Gatewayで使用するLambda
     */
    const lineUserProfileLambda = new nodejs.NodejsFunction(this, "lineUserProfileLambda", {
      entry: "server/handler/lineUserProfile.ts",
      runtime: lambda.Runtime.NODEJS_20_X,
      functionName: "lineUserProfileLambda",
      description: "サンプルのラムダ関数を作成",
      architecture: lambda.Architecture.ARM_64,
    });

    /**
     * API Gateway
     */
    const api = new apigateway.LambdaRestApi(this, "lineUserProfileApi", {
      handler: lineUserProfileLambda,
      proxy: false,
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: apigateway.Cors.DEFAULT_HEADERS,
        statusCode: 200,
      },

    });

    const lineUserProfileIntegration = new apigateway.LambdaIntegration(lineUserProfileLambda);
    api.root.addMethod("POST", lineUserProfileIntegration)

    /**
     * DynamoDB
     */
    const lineUserProfileTable = new aws_dynamodb.Table(
      this,
      "LineUserProfileTable",
      {
        partitionKey: {
          name: "id",
          type: aws_dynamodb.AttributeType.STRING,
        },
        billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST,
        removalPolicy: RemovalPolicy.RETAIN,
        tableName: "LineUserProfile",
        pointInTimeRecovery: true,
      },
    );

    lineUserProfileTable.grantReadWriteData(lineUserProfileLambda);
  }
}

クライアントサイド(React)

create-liif-appで簡単にLIFFアプリを構築し、App.tsxに以下のコードを記述しました。

App.tsx
import { useEffect, useState } from "react";
import liff from "@line/liff";
import "./App.css";
import axios from "axios";

export const LIFFApp = () => {
  useEffect(() => {
    liff.init({
      liffId: import.meta.env.VITE_LIFF_ID,
    });
  });

  const handleSubmit = async () => {
+   const accessToken = await liff.getAccessToken();
+   if (accessToken) {
+     const res = await axios.post(
+       "https://hogehoge", // サーバーサイドへのリクエストエンドポイント
+       { accessToken } 
+     );
    }
  };

  return (
    <div className="App">
      <h1>LIFF APP</h1>

      <div>
        <div className="button-container">
          <p>ユーザー情報を送信する</p>
          <button onClick={handleSubmit}>送信</button>
        </div>
      </div>
    </div>
  );
};
CSS
.App {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}

.button-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 20px;
}

button {
  width: 100px;
  height: 50px;
  font-size: 16px;
  background-color: #007bff;
  color: white;
  border: none;
  padding: 10px 20px;
  cursor: pointer;
  border-radius: 5px;
}

スクリーンショット 2024-09-29 14.00.10
ハイライトしているところが重要な点です。今回の実装ではアクセストークンをLIFF APIを用いて取得し、それをサーバーサイドに送信しています。
フロントエンドでは特にユーザー情報を取得していないところが重要ですね。(フロントで完結するユーザー情報なら取得して使用しても良い)

以下はアンチパターン

❌ダメな例
  const handleSubmit = async () => {
//  ユーザー情報を直接サーバーサイドに送信している
    const userData = await liff.getProfile();
    axios.post(
      "https://hogehoge",
      {
        userData: userData,
      }
    );
  };

サーバーサイド (Lambda関数)

server/handler/lineUserProfile.ts
import axios from "axios";
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

const dynamoDB = new DynamoDBClient({ region: 'ap-northeast-1' });
const dynamoDBDocumentClient = DynamoDBDocumentClient.from(dynamoDB);

export const handler = async (event: any) => {
  console.log(event);
  const { accessToken } = JSON.parse(event.body);

  try {
    // アクセストークンの検証
+   const verifyResponse = await axios.get("https://api.line.me/oauth2/v2.1/verify", {
+     params: { access_token: accessToken }
+   });

    // 検証が成功した場合、ユーザー情報を取得
    if (verifyResponse.status === 200) {
+     const userInfoResponse = await axios.get("https://api.line.me/v2/profile", {
+       headers: {
+         Authorization: `Bearer ${accessToken}`
+       }
      });

      // ここで、DynamoDBにユーザー情報を保存
      await dynamoDBDocumentClient.send(new PutCommand({
        TableName: 'LineUserProfile',
        Item: {
          id: userInfoResponse.data.userId,
          lineDisplayName: userInfoResponse.data.displayName,
          pictureUrl: userInfoResponse.data.pictureUrl,
          statusMessage: userInfoResponse.data.statusMessage
        }
      }));

      return {
        statusCode: 200,
        body: JSON.stringify({ message: "ユーザー情報を保存しました" })
      };

    } else {
      throw new Error("アクセストークンの検証に失敗しました");
    }
  } catch (error) {
    console.error("エラー:", error);
    return {
      statusCode: 400,
      body: JSON.stringify({ error: "無効なアクセストークン" })
    };
  }
};

サーバーサイドでは、受け取ったアクセストークンを検証し、LINEプラットフォームからユーザー情報を安全に取得しています。取得したユーザー情報はDynamoDBに保存されます。
アクセストークンの有効性を検証
GET https://api.line.me/oauth2/v2.1/verify

ユーザープロフィールを取得する
GET https://api.line.me/v2/profile

このような構成にすることで、サーバーサイドで安全にユーザー情報を扱うことができます!

動作確認

LIFFアプリから送信ボタンを押すと、無事DynamoDBに値が保存されていることが確認できます。
スクリーンショット 2024-09-29 14.13.43

おわりに

今回はLIFFアプリでユーザー情報を扱う際のアンチパターンと適切な実装方法をご紹介しました。LIFFアプリでユーザー情報を扱う際は、セキュリティとプライバシーを最優先に考える必要があります。以下が重要な点です。

  • クライアントサイドでは最小限の情報(トークン)のみを扱い、ユーザーの詳細情報は直接取得しない。
  • サーバーサイドでトークンを検証し、LINEプラットフォームから安全にユーザー情報を取得する。
  • 取得したユーザー情報を適切に処理し、必要な情報のみをデータベースに保存する。

基本的にはドキュメントに書いてある内容ですが、実際にコードの実装例を提示しながら解説してみました。
以上。どなたかの参考になれば幸いです。

参考

https://developers.line.biz/ja/docs/liff/using-user-profile/

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.